Skip to contentMethod: commonPrefix(String, String)
1: /*
2: * *********************************************************************************************************************
3: *
4: * TheseFoolishThings: Miscellaneous utilities
5: * http://tidalwave.it/projects/thesefoolishthings
6: *
7: * Copyright (C) 2009 - 2024 by Tidalwave s.a.s. (http://tidalwave.it)
8: *
9: * *********************************************************************************************************************
10: *
11: * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with
12: * the License. You may obtain a copy of the License at
13: *
14: * http://www.apache.org/licenses/LICENSE-2.0
15: *
16: * Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on
17: * an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the
18: * specific language governing permissions and limitations under the License.
19: *
20: * *********************************************************************************************************************
21: *
22: * git clone https://bitbucket.org/tidalwave/thesefoolishthings-src
23: * git clone https://github.com/tidalwave-it/thesefoolishthings-src
24: *
25: * *********************************************************************************************************************
26: */
27: package it.tidalwave.util.test;
28:
29: import javax.annotation.Nonnegative;
30: import javax.annotation.Nonnull;
31: import javax.annotation.Nullable;
32: import java.util.ArrayList;
33: import java.util.List;
34: import java.io.BufferedReader;
35: import java.io.ByteArrayInputStream;
36: import java.io.File;
37: import java.io.IOException;
38: import java.io.InputStream;
39: import java.io.InputStreamReader;
40: import java.nio.file.Files;
41: import java.nio.file.Path;
42: import com.github.difflib.DiffUtils;
43: import com.github.difflib.patch.AbstractDelta;
44: import com.github.difflib.text.DiffRowGenerator;
45: import it.tidalwave.util.Pair;
46: import lombok.experimental.UtilityClass;
47: import lombok.extern.slf4j.Slf4j;
48: import static java.util.stream.Collectors.*;
49: import static java.nio.charset.StandardCharsets.UTF_8;
50: import static it.tidalwave.util.Pair.indexedPairStream;
51:
52: /***********************************************************************************************************************
53: *
54: * A utility class to compare two text files and assert that they have the same contents.
55: *
56: * @author Fabrizio Giudici
57: *
58: **********************************************************************************************************************/
59: @UtilityClass @Slf4j
60: public class FileComparisonUtils
61: {
62: private static final String P_BASE_NAME = FileComparisonUtils.class.getName();
63:
64: public static final String P_TABULAR_OUTPUT = P_BASE_NAME + ".tabularOutput";
65: public static final String P_TABULAR_LIMIT = P_BASE_NAME + ".tabularLimit";
66:
67: private static final boolean TABULAR_OUTPUT = Boolean.getBoolean(P_TABULAR_OUTPUT);
68: private static final int TABULAR_LIMIT = Integer.getInteger(P_TABULAR_LIMIT, 500);
69: private static final String TF = "TEST FAILED";
70:
71: /*******************************************************************************************************************
72: *
73: * Asserts that two files have the same contents.
74: *
75: * @param expectedFile the file with the expected contents
76: * @param actualFile the file with the contents to probe
77: * @throws IOException in case of error
78: *
79: ******************************************************************************************************************/
80: public static void assertSameContents (@Nonnull final File expectedFile, @Nonnull final File actualFile)
81: throws IOException
82: {
83: assertSameContents(expectedFile.toPath(), actualFile.toPath());
84: }
85:
86: /*******************************************************************************************************************
87: *
88: * Asserts that two files have the same contents.
89: *
90: * @param expectedPath the file with the expected contents
91: * @param actualPath the file with the contents to probe
92: * @throws IOException in case of error
93: *
94: ******************************************************************************************************************/
95: public static void assertSameContents (@Nonnull final Path expectedPath, @Nonnull final Path actualPath)
96: throws IOException
97: {
98: log.info("******** Comparing files:");
99: logPaths(expectedPath, actualPath, "");
100: assertSameContents(fileToStrings(expectedPath), fileToStrings(actualPath), expectedPath, actualPath);
101: }
102:
103: /*******************************************************************************************************************
104: *
105: * Asserts that two collections of strings have the same contents.
106: *
107: * @param expected the expected values
108: * @param actual the actual values
109: *
110: ******************************************************************************************************************/
111: public static void assertSameContents (@Nonnull final List<String> expected, @Nonnull final List<String> actual)
112: {
113: assertSameContents(expected, actual, null, null);
114: }
115:
116: /*******************************************************************************************************************
117: *
118: * Checks whether two files have the same contents.
119: *
120: * @param expectedPath the file with the expected contents
121: * @param actualPath the file with the contents to probe
122: * @throws IOException in case of error
123: * @since 1.2-ALPHA-15
124: *
125: ******************************************************************************************************************/
126: public static boolean checkSameContents (@Nonnull final Path expectedPath, @Nonnull final Path actualPath)
127: throws IOException
128: {
129: return checkSameContents(fileToStrings(expectedPath), fileToStrings(actualPath), expectedPath, actualPath)
130: .isEmpty();
131: }
132:
133: /*******************************************************************************************************************
134: *
135: * Converts a string which contains newlines into a list of strings.
136: *
137: * @param string the source
138: * @return the strings
139: * @throws IOException in case of error
140: *
141: ******************************************************************************************************************/
142: @Nonnull
143: public static List<String> stringToStrings (@Nonnull final String string)
144: throws IOException
145: {
146: //return List.of(string.split("\n"));
147: return resourceToStrings(new ByteArrayInputStream(string.getBytes(UTF_8)));
148: }
149:
150: /*******************************************************************************************************************
151: *
152: * Reads a file into a list of strings.
153: *
154: * @param file the file
155: * @return the strings
156: * @throws IOException in case of error
157: *
158: ******************************************************************************************************************/
159: @Nonnull
160: public static List<String> fileToStrings (@Nonnull final Path file)
161: throws IOException
162: {
163: return Files.readAllLines(file);
164: }
165:
166: /*******************************************************************************************************************
167: *
168: * Reads a classpath resource (not a regular file) into a list of strings.
169: *
170: * @param path the path of the classpath resource
171: * @return the strings
172: * @throws IOException in case of error
173: *
174: ******************************************************************************************************************/
175: @Nonnull
176: public static List<String> resourceToStrings (@Nonnull final String path)
177: throws IOException
178: {
179: final var is = FileComparisonUtils.class.getClassLoader().getResourceAsStream(path);
180:
181: if (is == null)
182: {
183: throw new RuntimeException("Resource not found: " + path);
184: }
185:
186: return resourceToStrings(is);
187: }
188:
189: /*******************************************************************************************************************
190: *
191: * Reads an input stream into a list of strings. The stream is closed at the end.
192: *
193: * @param is the input stream
194: * @return the strings
195: * @throws IOException in case of error
196: *
197: ******************************************************************************************************************/
198: @Nonnull
199: public static List<String> resourceToStrings (@Nonnull final InputStream is)
200: throws IOException
201: {
202: try (final var br = new BufferedReader(new InputStreamReader(is, UTF_8)))
203: {
204: final var result = new ArrayList<String>();
205:
206: for (;;)
207: {
208: final var s = br.readLine();
209:
210: if (s == null)
211: {
212: break;
213: }
214:
215: result.add(s);
216: }
217:
218: return result;
219: }
220: }
221:
222: /*******************************************************************************************************************
223: *
224: * Given a string that represents a path whose segments are separated by the standard separator of the platform,
225: * returns the common prefix - which means the common directory parents.
226: *
227: * @param s1 the former string
228: * @param s2 the latter string
229: * @return the common prefix
230: *
231: ******************************************************************************************************************/
232: @Nonnull
233: public static String commonPrefix (@Nonnull final String s1, @Nonnull final String s2)
234: {
235: final var min = Math.min(s1.length(), s2.length());
236: var latestSeenSlash = 0;
237:
238:• for (var i = 0; i < min; i++)
239: {
240:• if (s1.charAt(i) != s2.charAt(i))
241: {
242:• return (i == 0) ? "" : s1.substring(0, Math.min(latestSeenSlash + 1, min));
243: }
244: else
245: {
246:• if (s1.charAt(i) == File.separatorChar)
247: {
248: latestSeenSlash = i;
249: }
250: }
251: }
252:
253: return s1.substring(0, min);
254: }
255:
256: /*******************************************************************************************************************
257: *
258: * Asserts that two collections of strings have the same contents.
259: *
260: * @param expected the expected values
261: * @param actual the actual values
262: * @param expectedPath an optional path for expected values
263: * @param actualPath an optional path for actual values
264: *
265: ******************************************************************************************************************/
266: private static void assertSameContents (@Nonnull final List<String> expected,
267: @Nonnull final List<String> actual,
268: @Nullable final Path expectedPath,
269: @Nullable final Path actualPath)
270: {
271: final var diff = checkSameContents(expected, actual, expectedPath, actualPath);
272:
273: if (!diff.isEmpty())
274: {
275: throw new AssertionError(String.join(System.lineSeparator(), diff));
276: }
277: }
278:
279: /*******************************************************************************************************************
280: *
281: * Checks whether two collections of strings have the same contents.
282: *
283: * @param expected the expected values
284: * @param actual the actual values
285: * @param expectedPath an optional path for expected values
286: * @param actualPath an optional path for actual values
287: * @return the differences
288: *
289: ******************************************************************************************************************/
290: private static List<String> checkSameContents (@Nonnull final List<String> expected,
291: @Nonnull final List<String> actual,
292: @Nullable final Path expectedPath,
293: @Nullable final Path actualPath)
294: {
295: final var deltas = DiffUtils.diff(expected, actual).getDeltas();
296:
297: if (deltas.isEmpty())
298: {
299: return List.of();
300: }
301:
302: if ((expectedPath != null) && (actualPath != null))
303: {
304: logPaths(expectedPath, actualPath, "TEST FAILED ");
305: }
306:
307: final var strings = toStrings(deltas);
308: strings.forEach(log::error);
309:
310: if (!TABULAR_OUTPUT)
311: {
312: log.error("{} You can set -D{}=true for tabular output; -D{}=<num> to set max table size",
313: TF, P_TABULAR_OUTPUT, P_TABULAR_LIMIT);
314: }
315: else
316: {
317: final var generator = DiffRowGenerator.create()
318: .showInlineDiffs(false)
319: .inlineDiffByWord(true)
320: .lineNormalizer(l -> l)
321: .build();
322: final var pairs = generator.generateDiffRows(expected, actual)
323: .stream()
324: .filter(row -> !row.getNewLine().equals(row.getOldLine()))
325: .map(row -> Pair.of(row.getOldLine().trim(), row.getNewLine().trim()))
326: .limit(TABULAR_LIMIT)
327: .collect(toList());
328:
329: final var padA = pairs.stream().mapToInt(p -> p.a.length()).max().getAsInt();
330: final var padB = pairs.stream().mapToInt(p -> p.b.length()).max().getAsInt();
331: log.error("{} Tabular text is trimmed; row limit set to -D{}={}", TF, P_TABULAR_LIMIT, TABULAR_LIMIT);
332: log.error("{} |-{}-+-{}-|", TF, pad("--------", padA, '-'), pad("--------", padB, '-'));
333: log.error("{} | {} | {} |", TF, pad("expected", padA, ' '), pad("actual ", padB, ' '));
334: log.error("{} |-{}-+-{}-|", TF, pad("--------", padA, '-'), pad("--------", padB, '-'));
335: pairs.forEach(p -> log.error("{} | {} | {} |", TF, pad(p.a, padA, ' '), pad(p.b, padB,' ')));
336: log.error("{} |-{}-+-{}-|", TF, pad("--------", padA, '-'), pad("--------", padB, '-'));
337: }
338:
339: strings.add(0, "Unexpected contents: see log above (you can grep '" + TF + "')");
340: return strings;
341: }
342:
343: /*******************************************************************************************************************
344: *
345: * Converts deltas to output as a list of strings.
346: *
347: * @param deltas the deltas
348: * @return the strings
349: *
350: ******************************************************************************************************************/
351: @Nonnull
352: private static List<String> toStrings (@Nonnull final Iterable<? extends AbstractDelta<String>> deltas)
353: {
354: final List<String> strings = new ArrayList<>();
355:
356: deltas.forEach(delta ->
357: {
358: final var sourceLines = delta.getSource().getLines();
359: final var targetLines = delta.getTarget().getLines();
360: final var sourcePosition = delta.getSource().getPosition() + 1;
361: final var targetPosition = delta.getTarget().getPosition() + 1;
362:
363: switch (delta.getType())
364: {
365: case CHANGE:
366: indexedPairStream(sourceLines).forEach(p -> strings.add(
367: String.format("%s exp[%d] *%s*", TF, sourcePosition + p.a, p.b)));
368: indexedPairStream(targetLines).forEach(p -> strings.add(
369: String.format("%s act[%d] *%s*", TF, targetPosition + p.a, p.b)));
370: break;
371:
372: case DELETE:
373: indexedPairStream(sourceLines).forEach(p -> strings.add(
374: String.format("%s -act[%d] *%s*", TF, sourcePosition + p.a, p.b)));
375: break;
376:
377: case INSERT:
378: indexedPairStream(targetLines).forEach(p -> strings.add(
379: String.format("%s +act[%d] *%s*", TF, targetPosition + p.a, p.b)));
380: break;
381:
382: default:
383: }
384: });
385:
386: return strings;
387: }
388:
389: /*******************************************************************************************************************
390: *
391: * Logs info about file comparison paths.
392: *
393: * @param expectedPath the expected path
394: * @param actualPath the actual path
395: * @param prefix a log prefix
396: *
397: ******************************************************************************************************************/
398: private static void logPaths (@Nonnull final Path expectedPath,
399: @Nonnull final Path actualPath,
400: @Nonnull final String prefix)
401: {
402: final var expectedPathAsString = expectedPath.toAbsolutePath().toString();
403: final var actualPathAsString = actualPath.toAbsolutePath().toString();
404: final var commonPath = commonPrefix(expectedPathAsString, actualPathAsString);
405: log.info("{}>>>> path is: {}", prefix, commonPath);
406: log.info("{}>>>> exp is: {}", prefix, expectedPathAsString.substring(commonPath.length()));
407: log.info("{}>>>> act is: {}", prefix, actualPathAsString.substring(commonPath.length()));
408: }
409:
410: /*******************************************************************************************************************
411: *
412: * Pads a string to left to fit the given width.
413: *
414: * @param string the string
415: * @param width the width
416: * @return the padded string
417: *
418: ******************************************************************************************************************/
419: @Nonnull
420: private static String pad (@Nonnull final String string, @Nonnegative final int width, final char padding)
421: {
422: return String.format("%-" + width + "s", string).replace(' ', padding);
423: }
424: }